Skip to content

feat(sandbox): add suspend and resume operations - #2653

Open
sjenning wants to merge 12 commits into
NVIDIA:mainfrom
sjenning:2652-sandbox-suspend-resume/sj
Open

feat(sandbox): add suspend and resume operations#2653
sjenning wants to merge 12 commits into
NVIDIA:mainfrom
sjenning:2652-sandbox-suspend-resume/sj

Conversation

@sjenning

@sjenning sjenning commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add storage-preserving suspend and resume operations for sandboxes. Suspending terminates sandbox compute while retaining /workspace; resuming restores compute with the retained workspace available.

Related Issue

Closes #2652

Changes

  • Add public and internal gRPC suspend/resume APIs and CLI subcommands
  • Implement durable lifecycle reconciliation in the gateway
  • Support suspend/resume in Docker, Podman, Kubernetes, and VM compute drivers
  • Add Rust, Go, and Python SDK support
  • Document the lifecycle behavior and update related agent skills
  • Cover workspace persistence and deletion of suspended sandboxes in E2E tests

Testing

  • mise run pre-commit passes
  • mise run test passes
  • mise run ci passes
  • mise run go:ci passes
  • Unit tests added/updated
  • Docker suspend/resume workspace-persistence E2E passes
  • Docker suspended-delete E2E passes

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)

Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
@copy-pr-bot

copy-pr-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@mrunalp mrunalp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff (excluding regenerated sdk/go/proto). The gateway state machine here is the strongest part of the change — durable intent before mutation, cancellation-safe workers, ambiguous-outcome reconciliation, and gate unification are all handled deliberately and backed by real tests. Most of my feedback is on the Kubernetes driver, where the suspend confirmation path has a timing problem that will surface on the happy path.

What works well

  • Persist intent before mutating compute. Suspending/Resuming is CAS-written before the driver call, so a crash mid-transition is recoverable. recover_persisted_lifecycle_transitions (compute/mod.rs:2108) retries those rows at startup ahead of startup_resume, and sandbox_phase_should_be_running correctly excludes Suspended/Suspending so startup recovery can't wake a suspended sandbox.
  • Detached workers so request cancellation can't strand a row. Once the durable transition commits, the driver call moves into an owned task holding the lifecycle gate and the RPC awaits the join handle. Mirrors the delete path, and request_cancellation_does_not_cancel_{suspend,resume}_worker cover both directions.
  • DeleteGateRegistryLifecycleGateRegistry. Suspend, resume, and delete now serialize against each other rather than only against themselves, with the gate → global-guard lock order still enforced by the guard token.
  • Ambiguous-outcome reconciliation. recover_failed_lifecycle (compute/mod.rs:1265) re-queries the driver outside the global lock and retains the transition when backend state can't be resolved, rather than asserting a running/stopped state it can't verify.
  • Resume requires a fresh supervisor session, and the apply_driver_snapshot clamp keeps a stale snapshot from promoting a suspended row.
  • VM driver ordering is careful: the marker is written before process handles are detached and cleared only after all restore preflight checks pass, so a failed resume stays durably suspended (failed_resume_preserves_suspended_state).

I also confirmed against upstream agent-sandbox that the Kubernetes retention claim in the driver README is accurate: reconcilePVCs runs only inside reconcileChildResources, which the suspend path bypasses, so volumeClaimTemplates are left intact.


1. Kubernetes suspend will time out on the happy path and roll the row back to Ready

driver.rs:932-955

The API surface the driver targets is correct — spec.operatingMode (Running/Suspended) and the Suspended condition are both real published v1beta1 API. The problem is what the condition means. From upstream computeSuspendedCondition:

if pod == nil {
    suspended.Status = metav1.ConditionTrue
    suspended.Reason = sandboxv1beta1.SandboxReasonSuspendedPodTerminated
    ...
}
// pod still present:
suspended.Reason = sandboxv1beta1.SandboxReasonSuspendedPodTerminating
suspended.Message = "Pod is terminating. Sandbox is suspending"

Suspended=True requires the pod object to be fully gone from the API, not merely terminating. So the wait must cover the controller's reconcile, the pod's full grace period, kubelet teardown, and a re-reconcile — against a budget of KUBE_API_TIMEOUT = 30s. Nothing in this crate sets terminationGracePeriodSeconds on the sandbox pod (git grep -in grace crates/openshell-driver-kubernetes/ is empty), so it's the Kubernetes default of 30s. That leaves zero headroom: any sandbox whose supervisor or agent process doesn't exit promptly on SIGTERM blows the budget before the pod can possibly be gone.

The rollback is what makes this user-visible. On timeout, recover_failed_lifecycle(expected_stopped=true) re-queries the driver and sees Suspended=False/SuspendedPodTerminating plus Ready=False with a non-terminal reason. derive_phase returns Provisioning, and driver_snapshot_confirms_stopped is false (it only matches containerexited/containerstopped), so observed_stopped=false ≠ expected_stopped=true and restore_lifecycle_snapshot writes the row back to Ready. Net effect:

  • openshell sandbox suspend fails with a timeout even though suspension is proceeding correctly.
  • The phase flaps Suspending → Ready → Suspended (the watcher does eventually correct it, since old_phase = Ready falls through the clamp to _ => phase).
  • cleanup_suspended_sandbox_sessions never runs on the watcher path, so stored SshSession records linger until the next explicit suspend or a gateway restart. The supervisor session dies with the pod, so that part is fine.

Two suggestions: derive the suspend wait budget from the pod's grace period instead of reusing the API-call timeout, and treat SuspendedPodTerminating as "in progress" in recover_failed_lifecycle so a progressing suspend retains Suspending rather than rolling back.

2. Mechanical issues in the same poll loop

driver.rs:939-948

  • The api.get(&kube_name) inside the loop is the only kube call in this file not wrapped in tokio::time::timeout(KUBE_API_TIMEOUT, …), and the deadline is checked after the call returns. A hung API server hangs suspend indefinitely.
  • Fixed 250 ms polling means up to ~120 GETs per suspend against the API server. Worth backing off, or watching instead.

3. status.replicas == 0 fallback is dead code

driver.rs:3222

Upstream v1alpha1 declares Replicas int32 with json:"replicas,omitempty" — non-pointer int with omitempty, so zero is dropped at marshal time and status.get("replicas") == Some(0) can never match on a suspended sandbox. Separately, current upstream main has no v1alpha1 suspension logic at all: both computeSuspendedCondition and the pod-delete path key off spec.OperatingMode only, with no spec.Replicas == 0 check.

So it's worth confirming what the v1alpha1 path actually does on a real legacy install. If an older controller honors spec.replicas but doesn't publish the condition, suspend times out and rolls back to Ready while the pod is genuinely gone — silent divergence. If it ignores spec.replicas entirely, the patch is a no-op and the rollback is correct. Either way the fallback branch as written can't help, so I'd drop it and require the condition on both versions.

4. No driver capability negotiation for suspend/resume

proto/compute_driver.proto:47

ResumeSandbox is a required RPC and GetCapabilitiesResponse (:58) carries no feature flags. Against an out-of-tree driver that hasn't implemented it, the gateway durably writes Suspending and then fails with UNIMPLEMENTED. Recovery does roll back correctly, but a capability bit would let handle_suspend_sandbox reject with FAILED_PRECONDITION before touching durable state. This is also a compat break for external driver implementations and should be called out in release notes.

5. TUI not updated

crates/openshell-tui/src/lib.rs:2684

phase_label still handles only Provisioning/Ready/Error/Deleting, so suspended sandboxes render as "Unknown" in the dashboard. The CLI, both SDKs, docs, and agent skills were all updated — this is the one surface that was missed.

6. The Suspended clamp is unconditional

compute/mod.rs:3429

SandboxPhase::Suspended => SandboxPhase::Suspended,

Once a row is Suspended, no driver signal can move it out — including Error and Deleting. stopped_container_snapshot_cannot_error_suspended_sandbox shows this is intentional, and the absent-resource case is covered by the new ComputeResourceMissing branch. But an out-of-band failure that isn't absence (PVC lost, an external kubectl delete setting a deletionTimestamp) will keep reporting Suspended. Is that the intent, or should Deleting/Error pass through the clamp?

7. Stuck Resuming has no terminal state

If compute starts but the supervisor never reconnects, the row stays Resuming indefinitely. suspend rejects it (requires Ready|Suspending), nothing times it out to Error, and the CLI just expires at 300s — retry-resume or delete are the only exits. Consider a Resuming deadline, or allowing suspend from Resuming.

8. Phase and conditions can contradict

compute/mod.rs:3443

The clamp rewrites status.phase but leaves the conditions ComposedPhase::apply_readiness_conditions already wrote, so a stale running snapshot can leave phase = Suspended alongside Ready: True in openshell sandbox get output. Narrow window — it's exactly the case stale_ready_snapshot_cannot_wake_suspended_sandbox covers, and that test asserts only the phase.

9. Podman error remapping is broader than the feature

crates/openshell-driver-podman/src/driver.rs:39

PodmanApiError::NotFound → ComputeDriverError::NotFound changes every unexpected 404 in the Podman driver from INTERNAL to NOT_FOUND, not just the new stop/resume path — and the gateway interprets NOT_FOUND from delete_sandbox as "already gone." Likely an improvement, but it's a behavior change outside the issue's scope.

10. Go SDK SandboxInterface gains three methods

Suspend, Resume, and WaitSuspended are additions to a public interface, which breaks any external implementer or hand-written mock. Fine to do — it just belongs in release notes.


Minor

  • OPENSHELL_LIFECYCLE_TIMEOUT (crates/openshell-cli/src/run.rs:2493) is undocumented in cli-reference.md and docs/.
  • The PR description says suspend retains /workspace; the actual mount is /sandbox (WORKSPACE_MOUNT_PATH), which is what the E2E sentinel correctly uses.
  • Docs don't state that how much survives differs by driver. Docker and Podman keep the whole container writable layer and the VM keeps the full overlay, but on Kubernetes the pod is recreated, so only /sandbox (the PVC) survives — installed packages and edits to /etc, /home, /tmp are lost. docs/sandboxes/manage-sandboxes.mdx currently reads as if resume is transparent; worth a sentence there since it's a user-visible expectation.
  • recover_persisted_lifecycle_transitions (compute/mod.rs:2111) lists at most 1000 sandboxes with no pagination, silently skipping lifecycle recovery beyond that. Same limitation as the existing TODO in this file, but new code inherits it.

Items 1–3 are what I'd want resolved before merge, since the Kubernetes path is the least verified — the Testing section lists Docker E2E only — and item 1 makes the happy path report failure. 4–7 are design questions worth answering; the rest is cleanup.

Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
@sjenning

Copy link
Copy Markdown
Collaborator Author

Addressed comments 1-3 and 5. The rest are edge cases I'm not convinced it is worth the complexity to address.

@mrunalp mrunalp added this to the OpenShell Beta milestone Aug 10, 2026
@mrunalp

mrunalp commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Items 1, 2, 3, and 5 are genuinely addressed — the Kubernetes happy path no longer reports failure, and the phase flap and lingering-session symptoms are both gone. I verified the upstream API assumptions against current agents.x-k8s.io main rather than my earlier notes. Three
residual items below, one of which I'd want changed before merge.

Verified fixed

1 — suspend timing. The budget now derives from spec.podTemplate.spec.terminationGracePeriodSeconds plus one API timeout of reconcile headroom (60s by default), and the path matches how this driver actually builds the CR. On the gateway side driver_snapshot_confirms_suspending retains Suspending instead
of rolling back, and the new apply_sandbox_update hook lets the watcher finish the transition including session cleanup. failed_suspend_retains_progress_and_watcher_completes_cleanup covers the whole sequence.

Matching both PodTerminating and PodNotTerminated is the right call, and worth keeping — upstream renamed this reason. The shipped controller I checked against earlier emits SandboxReasonSuspendedPodNotTerminated = "PodNotTerminated"; current main emits SandboxReasonSuspendedPodTerminating = "PodTerminating" and marks the old constant deprecated. The dual match covers both controller vintages. (My original comment quoted the KEP's name for it, not the implemented one.)

2 — poll loop. api.get is now inside tokio::time::timeout, the request timeout is clamped to the remaining budget, the deadline is checked before the call rather than after, and backoff runs 250ms → 2s. That's ~35 GETs per suspend instead of ~120.

3 — dead status.replicas fallback. Removed, and suspended_status_requires_published_condition documents why. The legacy pod-absence substitute mirrors upstream resolvePodName exactly (annotation, else sandbox name).

5 — TUI. phase_label plus list and detail-view styling and a test. Good catch extending it to sandbox_detail.rs and sandboxes.rs, which I hadn't flagged.

Residual

a. Session cleanup now runs on every snapshot, not on the transition

compute/mod.rs:2618

if sandbox.phase() == SandboxPhase::Suspended as i32 {
    self.cleanup_suspended_sandbox_sessions(&sandbox).await?;
}

This fires on every driver snapshot that resolves to Suspended, not only when the sandbox enters Suspended. A suspended Kubernetes Sandbox still generates watch events, and each one now performs a full 1,000-record SshSession workspace list plus per-record decode while holding sync_lock. The operations
are idempotent, so it's correctness-safe, but it's avoidable work on the global lock that scales with workspace size × event rate. Gating on the pre-update phase (existing/old_phase != Suspended) keeps the watcher-completion behavior with none of the repeat cost.

b. Legacy pod-absence check can confirm suspension on the first poll

driver.rs:970-975, driver.rs:3280

kubernetes_sandbox_pod_is_gone treats a 404 as confirmed suspension, and it runs on the first iteration — before the controller has necessarily done anything. Two things make that load-bearing on a name that may not be right:

  • agents.x-k8s.io/pod-name is a v1beta1 annotation (warm-pool pod adoption), and this check only runs on the v1alpha1 path, so the annotation will never be set there and pod_name always falls back to kube_name. The annotation read is effectively dead code in the branch that uses it.
  • If a legacy controller names the backing pod anything other than the Sandbox name, the first get 404s, stop_sandbox returns Ok, and the gateway marks the row Suspended and revokes SSH and supervisor sessions while the pod is still running. That's a worse failure than the timeout it replaces, because it's
    silent.

Current upstream main has no v1alpha1 API at all (api/ contains only v1beta1), so this path is speculative either way. If nobody has a v1alpha1 install to test against, requiring the published condition on both versions — my original suggestion — fails closed instead: suspend times out and rolls back to
Ready, which is the correct outcome if the controller ignores spec.replicas.

c. Two newer upstream reasons fall through to the full timeout

Current main added SandboxReasonSuspendedPodNotOwned = "PodNotOwned" (Suspended=False, "Refused to delete pod because it is not owned by this sandbox") and SandboxReasonSuspendedPodStateUnknown = "PodStateUnknown" (Suspended=Unknown). Neither matches driver_snapshot_confirms_suspending, so both burn the
full 60s and then roll back to Ready. The end state is right — these aren't progressing — but PodNotOwned in particular is terminal and worth failing fast on rather than polling for a minute. Low priority.

Minor

  • The new test uses reason: "PodTerminating". That's the constant current main emits, so it's the better choice for the primary case — but the value older controllers actually emit (PodNotTerminated) is now covered only by the match arm and never exercised. A second case, or a parameterized one, closes that.
  • apply_driver_snapshot's new arm sits above SandboxPhase::Suspending if phase != SandboxPhase::Error, which already retains Suspending in every other case. Its only distinct effect is suppressing a derived Error while suspension progresses. That's the right behavior, but it isn't obvious from reading the
    arm and no test covers it — a comment would help the next reader.
  • Testing still lists Docker E2E only, and all four fixes are Kubernetes-only with unit coverage of the pure helpers. The poll loop and the legacy pod check have no integration exercise.
  • I'd still take the two doc lines from the original review: OPENSHELL_LIFECYCLE_TIMEOUT is undocumented, and docs/sandboxes/manage-sandboxes.mdx reads as if resume is transparent when on Kubernetes only /sandbox survives — installed packages and edits to /etc, /home, /tmp are lost because the pod is
    recreated. That's a user-visible expectation, not an edge case. The PR description also still says /workspace; the mount is /sandbox.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sandbox): add storage-preserving suspend and resume lifecycle

2 participants